Skip to content

Add factory:setup + upfront prerequisite checks for pnpm factory:go - #5591

Merged
lukemelia merged 5 commits into
mainfrom
cs-12186-a-fresh-cardstackboxel-checkout-cannot-run-pnpm-factorygo
Jul 28, 2026
Merged

Add factory:setup + upfront prerequisite checks for pnpm factory:go#5591
lukemelia merged 5 commits into
mainfrom
cs-12186-a-fresh-cardstackboxel-checkout-cannot-run-pnpm-factorygo

Conversation

@lukemelia

@lukemelia lukemelia commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Problem

On a fresh pnpm install-only checkout, pnpm factory:go could not run end-to-end. It failed with a chain of four distinct blockers, each surfacing as a separate opaque crash from deep inside a run, and each requiring monorepo-internals knowledge to fix:

  1. No working Node pin — root package.json requires node >=24, but the only pin was .nvmrc (Volta doesn't read it); software-factory's volta.extends: "../../package.json" dead-ended because the root had no volta block.
  2. Unbuilt boxel-cli/dist/api.js — the entrypoint imports @cardstack/boxel-cli/api, which resolves to a gitignored dist/api.js that nothing in the factory:go path builds → ERR_MODULE_NOT_FOUND at module load.
  3. Host build lacking dev-mode test entriesboxel test's harness needs host/dist/tests/index.html, only emitted by a dev-mode host build.
  4. Missing Playwright browser binaryboxel test launches headless Chromium; the binary was never downloaded.

None were documented, and no error pointed at the next blocker in the chain.

Fix

Attacks the root cause (factory:go did zero upfront validation) rather than committing build artifacts:

  • src/preflight.ts (new) — detects all four prerequisites. Imports nothing from boxel-cli, so it can run before the dist/api.js import that used to crash first.
  • src/cli/factory-entrypoint.ts — runs preflight up front and defers the boxel-cli import until it passes. Missing prerequisites produce one consolidated message pointing at pnpm factory:setup; every missing item is listed with its individual fix command. Preflight is skipped for --help, so usage stays available on a provisioned checkout; on one that hasn't built dist/api.js the deferred import throws and the catch re-runs the check, so --help prints the prerequisites list and exits 1 — more useful there than raw usage.
  • scripts/factory-setup.ts + factory:setup script (new) — idempotent bootstrap that builds the missing artifacts and installs the Playwright browser, skipping anything already present. --force rebuilds everything.
  • Root package.json — added a real volta pin (node: 24.17.0, matching .nvmrc/devEngines) so the existing volta.extends chain resolves.
  • README — documented the one-time setup and the Node requirement.

Verification

  • Preflight self-check reports the correct satisfied/missing set and formats the consolidated message.
  • --help on a provisioned checkout → usage, exit 0; on an unprovisioned one → the prerequisites list, exit 1.
  • Simulated missing dist/api.js → clean consolidated message + exit 1 (no ERR_MODULE_NOT_FOUND).
  • factory:setup no-op run skips all three steps, exit 0.
  • eslint + prettier clean; ember-tsc --noEmit clean.

Out of scope

This handles missing artifacts on a fresh checkout. A stale dist/api.js (present but built from older source) still passes the existence check yet can crash on a missing named export; factory:setup --force fixes it, but automatic staleness detection is a possible follow-up.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Host Test Results

    1 files  ± 0      1 suites  ±0   2h 33m 21s ⏱️ + 8m 37s
3 677 tests +15  3 662 ✅ +15  15 💤 ±0  0 ❌ ±0 
3 696 runs  +15  3 681 ✅ +15  15 💤 ±0  0 ❌ ±0 

Results for commit 38ecdac. ± Comparison against earlier commit 978060f.

Realm Server Test Results

    1 files  ±    0      1 suites  ±0   11m 22s ⏱️ - 1m 34s
1 943 tests +    8  1 942 ✅ +    7  0 💤 ±0  1 ❌ +1 
4 044 runs  +2 030  4 043 ✅ +2 029  0 💤 ±0  1 ❌ +1 

Results for commit 38ecdac. ± Comparison against earlier commit 978060f.

For more details on these errors, see this check.

A fresh checkout could not run pnpm factory:go without four undocumented
manual fixes, each surfacing as a separate opaque crash from deep inside a
run: no working Node pin, an unbuilt boxel-cli dist/api.js, a host build
lacking dev-mode test entries, and a missing Playwright browser binary.

- Add src/preflight.ts: detects all four prerequisites (Node >= 24,
  boxel-cli/dist/api.js, host/dist/tests/index.html, Playwright
  chromium-headless-shell) without importing boxel-cli, so the check runs
  before the dist/api.js import that would otherwise crash first.
- factory-entrypoint.ts now runs preflight up front and defers the
  boxel-cli import until it passes, reporting every missing prerequisite at
  once and pointing at pnpm factory:setup. --help still works.
- Add scripts/factory-setup.ts (pnpm factory:setup): idempotent bootstrap
  that builds the missing artifacts and installs the Playwright browser,
  skipping anything already present (--force rebuilds all).
- Add a real volta pin to the root package.json so the existing
  volta.extends chain in software-factory resolves a Node version.
- Document the one-time setup and Node requirement in the README.

CS-12186

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lukemelia
lukemelia force-pushed the cs-12186-a-fresh-cardstackboxel-checkout-cannot-run-pnpm-factorygo branch from b5a9fc1 to 497c830 Compare July 27, 2026 12:47
lukemelia and others added 3 commits July 27, 2026 09:02
Two integration cases move HOME to isolate the boxel profile, but preflight
resolves the Playwright browser cache from HOME as well. Under a temp home it
finds no `chromium_headless_shell-*` build and refuses to run the factory, so
the `--debug` summary case exits 1 instead of 0 and the no-profile case reports
a missing browser binary rather than the `boxel profile add` guidance it
asserts. Installing browsers in CI doesn't help — they are installed, just not
under the temp home.

Pin those two children to the cache the test process itself resolves, so only
the profile is isolated. `playwrightBrowsersRoot` is exported for that; it
returns undefined only under `PLAYWRIGHT_BROWSERS_PATH=0`, which the inherited
env already carries through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`factory-target-realm.spec.ts` relocates HOME for the same profile-isolation
reason as the integration cases, so preflight aborts there as well and no target
realm is ever created. The spec asserts `status === 1` because the loop fails
without an API key, and a preflight abort exits 1 too — so that assertion still
passed and the run failed later, as a 404 fetching a session for the realm that
was never made.

Move the browser-cache env into `tests/helpers/preflight-env.ts` and use it at
all three spawn sites rather than repeating it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…stackboxel-checkout-cannot-run-pnpm-factorygo
@lukemelia
lukemelia requested review from a team and jurgenwerk July 27, 2026 17:38

@habdelra habdelra left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖]

Review: factory:setup + upfront preflight for pnpm factory:go

I focused this review on the failure-ordering contract that makes the fix work — preflight must run before the @cardstack/boxel-cli/api import, and both preflight and factory:setup must load without that build artifact present — plus whether the detection heuristics and version pins actually match the code they claim to. Verified empirically against the checked-out head branch where checkable.

Bottom line: no blocking issues. The design attacks the right root cause (upfront validation + a deferred boxel-cli import) rather than committing build artifacts, and the load-bearing invariants hold.

What lands right (verified)

  • Deferred-import ordering. missingPrerequisites() runs before import('@cardstack/boxel-cli/api'), and every static top-level import in src/cli/factory-entrypoint.ts (setup-logger, logger, preflight) is boxel-cli-free — so a fresh checkout gets the consolidated message instead of ERR_MODULE_NOT_FOUND. Confirmed preflight.ts loads with no dist/api.js present and returns a correct satisfied/missing set.
  • Version pins line up. .nvmrc = 24.17.0, root devEngines.runtime = 24.17.0, and the new root volta.node = 24.17.0; packages/software-factory carries volta.extends: "../../package.json", so the previously dead-ended extends chain now resolves. PINNED_NODE / MIN_NODE_MAJOR match.
  • Build claims are accurate. @cardstack/host's build is vite build --mode=development, and the vite config comment explicitly names "software-factory tests" as a dev-mode-build consumer — so dist/tests/index.html is emitted by exactly the command the fix points at. boxel-cli's build:api script exists and its ./api export resolves to the gitignored ./dist/api.js.
  • Test env fix is necessary and correct. Because preflight resolves the Playwright cache from HOME, the relocated-HOME tests would fail preflight before reaching the profile/arg errors they assert; spreading playwrightBrowsersEnv after the HOME override pins the child to the real cache while still isolating the profile. The helper's doc comment matches its behavior.

Non-blocking recommendations

  1. wantsHelp in the CLI duplicates wantsFactoryEntrypointHelp in the module — forced by the deferred import, but a drift risk. Suggest a single home in preflight.ts. (see inline thread on wantsHelp)
  2. Guard the boxel-cli-free invariant for the next editor — it also binds factory-setup.tssetup-logger.tslogger.ts. (see inline thread on the preflight.ts header)
  3. Test hermeticity. With preflight now running inside the spawned factory:go, the two tests that assert arg-parse / usage behavior ("fails clearly when required inputs are missing", "prints usage with --help") can only reach those assertions once every prerequisite is provisioned. In particular, "fails clearly when required inputs are missing" carries neither a relocated HOME nor playwrightBrowsersEnv, so on a machine that has never run factory:setup / playwright install it will fail with the preflight "Cannot run the factory" message instead of its asserted Missing required --brief-url. That's an acceptable consequence of testing the real entrypoint — flagging only that these two are no longer hermetic; no change requested.
  4. Evergreen comments (low priority). The new comments carry a tracker ID (the CS-12186 reference in the preflight.ts / factory-setup.ts / CLI headers). The repo's evergreen-comments guidance discourages tracker refs in code comments, and these read fine without the ID, which will outlive the ticket. The surrounding code already carries legacy CS-… refs, so this is convention-consistency, not a regression.

Confirmations on the scope notes

  • The out-of-scope stale dist/api.js case checks out: import('@cardstack/boxel-cli/api') resolves for a stale-but-present bundle, and let { BoxelCLIClient } = cli simply binds undefined, deferring the failure to BoxelCLIClient.ensureProfile (caught, logged as a stack). --force is the escape hatch. Reasonable to defer.
  • One nuance on the description: "--help still works" holds on a provisioned checkout. On a checkout that hasn't built dist/api.js, --help now prints the prerequisites list and exits 1 (preflight is skipped for help, the deferred import throws, and the catch re-runs the check). The in-code comment is precise about this, and the behavior is arguably more useful than raw usage — I'd leave it; just noting the description slightly overstates it.

Generated by Claude Code

Comment on lines +12 to +15
function wantsHelp(argv: string[]): boolean {
let normalized = argv[0] === '--' ? argv.slice(1) : argv;
return normalized.includes('--help');
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖]

Non-blocking — drift-by-duplication. This wantsHelp is identical in logic to wantsFactoryEntrypointHelp in the module (src/factory-entrypoint.ts):

let normalized = argv[0] === '--' ? argv.slice(1) : argv;
return normalized.includes('--help');

The duplication is forced — you can't call the module's version before the deferred @cardstack/boxel-cli/api import, and running the help check up front is the whole point. But two copies of the same argv rule drift the first time one learns something (adding a -h short flag, accepting bare help, etc.): the CLI would skip preflight while the module still renders usage, or vice-versa, and nothing fails loudly — the two decisions just quietly disagree.

Single home: put this predicate in preflight.ts (it imports nothing from boxel-cli and is already statically imported here), then import it in both the CLI and the module; wantsFactoryEntrypointHelp can become a thin re-export for its existing callers.

Not blocking — the copies are correct today.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Fixed in 38ecdac — and gone rather than shared, which turned out to be possible.

Your read of the constraint was exactly right: the CLI has to answer the help question before importing the entrypoint module, so it could not call the module's copy. But it did not need a second copy either. The predicate now lives in preflight.ts — already statically imported here, boxel-cli-free by construction, and it imports nothing back, so there is no cycle — and the CLI uses it directly:

let helpRequested = wantsFactoryEntrypointHelp(argv);

src/factory-entrypoint.ts re-exports it (export { wantsFactoryEntrypointHelp } from './preflight.ts'), so the four assertions in factory-entrypoint.test.ts and any other importer keep reaching it under the same name with no change.

One extra bit of duplication fell out with it: the CLI was calling the predicate a second time after the deferred import (if (wantsFactoryEntrypointHelp(argv))) having already computed helpRequested at the top. That branch now reuses the value, so the argv rule is consulted once per run and defined once in the tree — adding -h later is a one-line change with no way for the two sides to disagree.

Comment on lines +9 to +11
// It deliberately imports nothing from `@cardstack/boxel-cli` — one of the
// prerequisites it checks for is boxel-cli's own `dist/api.js`, so importing it
// here would defeat the purpose (the import would crash before the check runs).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖]

Confirmation — this is the load-bearing invariant of the PR, worth guarding explicitly. The comment is exactly right, and I verified it: importing ./preflight.ts in a worktree with no packages/boxel-cli/dist/ present loads cleanly and returns a sane prerequisite set. So pnpm factory:setup — the thing that builds dist/api.js — can itself run on a truly fresh checkout without the ERR_MODULE_NOT_FOUND it exists to cure.

One thing for the next editor, because it's one indirection away and easy to miss: the same invariant binds factory-setup.ts's transitive imports. It pulls in setup-logger.tslogger.ts, which only require('@cardstack/logger') (a published CJS package, no build step) — not boxel-cli. If anything in the preflight.ts / setup-logger.ts / logger.ts chain ever grows a runtime import of @cardstack/boxel-cli/api, factory:setup regresses to crashing on the exact fresh checkout it's meant to fix, and the failure would look unrelated to the change that caused it. A one-line reminder here (or on the factory-setup.ts import block) would make that trap visible.

Nothing to change.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Taken — the reminder is in, in 38ecdac. You were right that the trap is one indirection away and that the failure would not point at its cause, which is exactly the kind of thing worth writing down rather than trusting the next reader to re-derive.

The header now names the chain explicitly:

That constraint is load-bearing for more than this file: factory:setup is what builds dist/api.js, and it reaches this module through scripts/factory-setup.tssetup-logger.tslogger.ts. Every link in that chain has to stay boxel-cli-free. If one of them grows a runtime import of @cardstack/boxel-cli/api, factory:setup starts crashing on exactly the fresh checkout it exists to fix, and the failure won't look related to whatever change caused it.

I re-verified the chain while writing it: logger.ts only requires @cardstack/logger (published CJS, no build step), setup-logger.ts imports just logger.ts, and this module imports nothing but node builtins. So the invariant holds today as you found it.

The same commit also dropped the tracker ID from your fourth summary point — it was in three places (this header, the CLI preflight comment, and factory-setup.ts) and, as you noted, all three read fine without it.

The argv rule for `--help` existed twice: the CLI needs the answer before it can
import the entrypoint module, since that import pulls in boxel-cli. Both copies
were correct, but they'd have disagreed silently the first time one of them
learned a new spelling. It now lives in preflight.ts — which the CLI already
imports and which is boxel-cli-free by construction — and the entrypoint module
re-exports it so its existing callers are unaffected. The CLI reuses the value it
already computed rather than asking a second time.

Record that the boxel-cli-free constraint covers more than preflight.ts: it also
binds factory-setup.ts and the setup-logger/logger chain it pulls in. Breaking
any link there makes factory:setup crash on the fresh checkout it exists to fix,
with a cause that looks unrelated.

Skip the two tests that assert on argument handling when prerequisites are
missing, but never in CI. They spawn the real package script, so preflight now
refuses inside the child before arg parsing runs — meaning they failed on exactly
the fresh checkout this work is about, pointing at arg parsing when the cause was
unbuilt artifacts. In CI the artifacts are built as part of the run, so a missing
prerequisite stays a loud failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lukemelia

Copy link
Copy Markdown
Contributor Author

[Claude Code 🤖] All five points addressed in 38ecdac (plus a description fix). The two with inline threads are answered there; these are the other three.

Test hermeticity (your #3) — acted on, despite the waiver. You flagged this as an acceptable consequence with no change requested. I went ahead anyway, because of where the failure lands: both tests would fail on precisely the fresh checkout this PR exists to improve, and they would fail pointing at argument parsing when the real cause is unbuilt artifacts. Someone who clones, installs, and runs the suite gets the most misleading possible version of the problem preflight was added to explain.

Confirmed both halves of your analysis first:

  • fails clearly when required inputs are missing spawns with no env overrides at all, so preflight refuses before parsing and the asserted Missing required --brief-url never happens.
  • prints usage with --help asserts status === 0, but preflight is skipped for --help, the deferred import then throws, the catch re-runs the check, and it exits 1.

Both are now defined as test.skip when missingPrerequisites() is non-empty — resolved at definition time, so they register as skipped rather than reporting a pass they never performed — with a console.warn naming what is missing and pointing at pnpm factory:setup. Never skipped when CI is set: there the artifacts are built as part of the run, so a missing prerequisite has to stay a loud failure instead of quietly reducing coverage.

(First attempt used an early return inside the tests; qunit/no-early-return rejected it, correctly — the definition-time choice is the better shape anyway.)

Evergreen comments (your #4) — done. Three occurrences removed (preflight.ts header, the CLI preflight comment, factory-setup.ts header). Agreed on your framing: the neighbouring legacy CS-… refs make this convention-consistency rather than a regression, but they do not license new ones. Left the legacy refs alone as out of scope.

The --help nuance — description corrected, behavior kept. You were right that the description overstated it in two places. Both now say what actually happens: usage and exit 0 on a provisioned checkout, the prerequisites list and exit 1 on one that has not built dist/api.js. Agreed the behavior itself is the more useful of the two there, so that stayed as-is.

Verification. pnpm lint clean across all four checks (eslint, prettier, template-lint, ember-tsc). Node suite: 474 pass, 1 fail — port-allocator > IPv4 holder blocks a default (dual-stack) bind on the same port, which I confirmed fails identically on this branch with my changes stashed (macOS dual-stack behavior, green in CI). The two arg-handling tests pass here, since this machine is provisioned and they take the real path rather than the skip.

Also worth noting for anyone reading the earlier red CI on this PR: the host-test failure was the persist-scroll-position flake, already fixed on main by #5607 and picked up by merging main in — host tests are 0 ❌ now, with only the known Memory Baseline flap outstanding.

@lukemelia
lukemelia merged commit 455550c into main Jul 28, 2026
100 of 103 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants